Skip to content

feat(server): serveStdio — connection-pinned era serving for stdio; remove ServerOptions.eraSupport#2315

Merged
felixweinberger merged 14 commits into
v2-2026-07-28from
fweinberger/serve-stdio
Jun 18, 2026
Merged

feat(server): serveStdio — connection-pinned era serving for stdio; remove ServerOptions.eraSupport#2315
felixweinberger merged 14 commits into
v2-2026-07-28from
fweinberger/serve-stdio

Conversation

@felixweinberger

Copy link
Copy Markdown
Contributor

Adds serveStdio, a stdio entry point that mirrors createMcpHandler for long-lived connections: the entry owns the transport and the protocol-version era decision, the client's opening exchange selects the era for the connection, and one server instance built from a consumer factory is pinned to that connection and serves only that era. The per-message dual-era option an earlier 2.0 alpha added to ServerOptions (eraSupport) is removed along with the per-message era machinery it required.

Motivation and Context

The 2026-07-28 draft revision is served over HTTP by createMcpHandler, which classifies each request before constructing a per-request server instance — so every instance speaks exactly one protocol era, selected by the entry. stdio previously took a different approach: a single hand-constructed instance with eraSupport: 'dual-era' classified every message and switched eras per message, which pushed era-selection branches into the protocol dispatch layer and the Server class. This PR gives stdio the same shape as HTTP: an entry owns the era decision, instances stay single-era, and the per-message machinery is deleted.

The specification supports the connection-pinned model: era determination is a property of the server (clients cache it for the stdio process lifetime), a dual-era server selects its behavior from how the client opens (an initialize opening selects legacy semantics scoped to the stdio process), and serving both eras concurrently on one process is only a MAY.

What changed:

  • serveStdio(factory, options?) (exported from @modelcontextprotocol/server/stdio): owns the stdio transport (or a bring-your-own transport, e.g. over a Unix domain socket), classifies the connection's opening exchange with the same body-primary rules as the HTTP entry, builds ONE instance from the factory for that era, pins it for the connection lifetime, and passes everything else straight through. A server/discover probe is answered from an optimistically built modern instance without pinning; the client either continues with enveloped modern requests (pinning the modern era) or falls back to initialize (the probe instance is discarded and a fresh 2025-era instance serves the handshake). Once the modern era is pinned, a later initialize is rejected with the unsupported-protocol-version error naming the supported revisions, as the spec recommends. legacy: 'reject' refuses 2025-era openings the same way; the default serves them.
  • ServerOptions.eraSupport removed (it only ever existed in unreleased 2.0 alphas). A hand-constructed Server/McpServer serves the 2025-era protocol it was written for — upgrading the SDK changes nothing about its wire behavior — and serving the 2026-07-28 revision always goes through a serving entry. With the option gone, the per-message machinery it required is deleted: the per-message era selection in the protocol dispatch layer, the server-side per-message classification override, the dual-era initialize bookkeeping, the per-request era wrapping of context senders (the instance-level outbound era gate covers pinned instances), and the special-cased server/discover registration for unbound instances. The protocol-layer hook that remains can only drop unclassified inbound messages — it is what lets a client on a modern-era connection discard inbound requests instead of answering them — and can no longer influence era selection.
  • Examples, integration and e2e coverage migrated: the dual-era stdio example now uses serveStdio (one factory, both eras, each client on its own connection); the real-pipe integration suite covers a legacy-opening connection, a modern-opening connection (including the late-initialize rejection), and the probe-then-fallback flow against a real child process; the package-level suite covers the full opening state machine over an in-memory transport.
  • Docs: migration guide and server guide describe the entry and the one-line migration from eraSupport; a changeset records the new entry and the removal.

How Has This Been Tested?

  • New unit suite for the entry's opening state machine (legacy opening, modern opening, enveloped initialize, probe→modern, probe→initialize fallback with the probe instance discarded, initialize after the modern era is pinned, legacy: 'reject', malformed and unsupported envelope claims, teardown), plus a golden pin asserting a hand-constructed server still serves a scripted 2025 session byte-shape-identically and keeps answering server/discover with -32601.
  • Real child-process integration coverage over the stdio pipe for both connection kinds and the fallback flow; the runnable example drives both legs from source.
  • Full repo gates: typecheck, lint, build, docs build, every package suite, integration, the full e2e matrix, and the conformance suites against the published referee with no expected-failures changes.

Breaking Changes

ServerOptions.eraSupport is removed (it never shipped in a stable release). Migrate new McpServer(info, { eraSupport: 'dual-era' }) + connect(new StdioServerTransport()) to serveStdio(() => new McpServer(info)), and eraSupport: 'modern' to serveStdio(factory, { legacy: 'reject' }). Hand-constructed servers are otherwise byte-identical to before, and a 2026-era revision in supportedProtocolVersions no longer throws at construction.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Factories should be cheap and side-effect-free to construct: the entry may build an instance for a probe that is later discarded (and the HTTP entry already constructs one per request). This is documented on the entry.
  • Instances are connected to a thin per-connection channel rather than to the stdio transport itself; the entry keeps ownership of the transport, which is what makes the probe-fallback discard clean and keeps close() semantics predictable.
  • On connections pinned to the 2026 era the deprecated connection-scoped client-identity accessors return undefined (client identity is per-request there); handlers read ctx.mcpReq.envelope. Documented in the migration guide.

…nly hook

The classification consult added for per-message dual-era serving selected a
wire codec per message on unbound instances. Era is connection state owned by
the serving entries, so the hook no longer returns classifications: it can
only decline a message ('drop'), which is what the client uses to discard
inbound requests on modern-era connections. The per-message predicate
classifyInboundMessage is removed with its only consumer; the
carriesValidModernEnvelopeClaim helper is exported on the internal barrel for
the stdio serving entry.
…erOptions.eraSupport

serveStdio(factory, options?) (exported from the ./stdio subpath) owns the
stdio transport and the era decision for a connection: the opening exchange
selects the era (initialize/claim-less => 2025, valid modern envelope => 2026,
server/discover answered as a probe with an initialize fallback window), one
factory instance is pinned for the connection lifetime, and later messages
pass straight through. legacy: 'reject' answers 2025-era openings with the
unsupported-protocol-version error naming the supported revisions.

ServerOptions.eraSupport (an earlier alpha's per-message dual-era option) is
removed along with the per-message machinery it required: the Server
classification override, the dual-era initialize bookkeeping, and the
per-request context era wrapping (the instance-level outbound era gate covers
pinned instances). Hand-constructed servers keep their pre-existing 2025-only
behavior, including discover registration keyed on the supported-versions
list.
The stdio example, the e2e dual-era stdio fixture/scenario, and the real-pipe
integration suite now host the server through serveStdio. The integration
suite covers one legacy-opening connection and one modern-opening connection
against the same factory (replacing the interleaved-eras-on-one-connection
assertions), plus the probe-then-initialize fallback and the
initialize-after-modern-pinned rejection over a real child-process pipe. The
tests that had only added the removed eraSupport option to satisfy its
construction-time guard are restored to their previous form.
Migration guide and server guide now describe the connection-pinned stdio
entry (factory, opening-exchange rules, legacy: 'reject', BYO transport) and
the one-line migration from the removed ServerOptions.eraSupport; the
changeset for the unreleased option is replaced accordingly.
@felixweinberger felixweinberger requested a review from a team as a code owner June 17, 2026 12:01
@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e70d007

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@modelcontextprotocol/server Minor
@modelcontextprotocol/express Major
@modelcontextprotocol/fastify Major
@modelcontextprotocol/hono Major
@modelcontextprotocol/node Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2315

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2315

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2315

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2315

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2315

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2315

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2315

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2315

commit: caf8c9d

Comment thread packages/server/src/server/serveStdio.ts
Comment thread packages/server/src/server/serveStdio.ts
Comment thread docs/migration.md Outdated
…d or connect an instance

A factory that throws or rejects, or an instance whose connect fails, while
serveStdio is processing an inbound request previously left that request
unanswered: the pump's catch only reported the error, so the client hung on
its opening exchange. The catch now answers the request with an internal
error (-32603) echoing its id before reporting, mirroring how the HTTP entry
answers a throwing factory with its internal-server-error response.
Notifications are still only reported. Every classification arm that writes
an error response does so via writeErrorResponse (which never throws) and
returns immediately, so the catch can only fire for requests that were never
answered - no double response is possible.
…ted server/discover probes

The probe special-case only matched while the connection was still in the
opening phase, so a second server/discover received during the probe phase
fell into the modern-commitment branch and pinned the connection. After that
a legitimate fallback initialize was rejected with -32004 instead of being
served by a fresh legacy instance. A server/discover received in the probe
phase is now answered by the existing probe instance without changing phase;
only a non-discover enveloped request commits the connection to the modern
era.
…probe instance

When a client pipelines its fallback initialize directly behind a
server/discover probe without waiting for the answer, the probe-instance
discard closed the channel while the DiscoverResult write was still in
flight: closing aborts the in-flight handler, the late send was dropped
silently, and the probe request was never answered. The connection channel
now tracks delivered-but-unanswered request ids, and the discard path waits
for those answers to reach the wire before closing the probe instance. The
probe instance only ever receives server/discover, whose entry-installed
handler always answers, so the wait is bounded; a channel close releases any
remaining waiters.
…ound

The hook can only return 'drop' or undefined since the per-message era
classification was removed, so the old name no longer described what it
does. Pure rename of the protected member, its Client override, and the
covering suite (file renamed to match); no behavior change.
…ed serveStdio connection

A handler calling ctx.mcpReq.requestSampling on a connection serveStdio
pinned to the 2026-07-28 era gets the typed method-not-supported error
locally, and no sampling request reaches the wire.
…o connections by accessor

The migration guide and its skill variant claimed all three deprecated
accessors return undefined on a serveStdio connection pinned to 2026-07-28.
Only the identity accessors (getClientCapabilities, getClientVersion) do;
getNegotiatedProtocolVersion reports the pinned revision because the entry
era-marks the instance when binding it, matching its JSDoc and
createMcpHandler-served instances. Docs only, no code change.
Comment thread packages/server/src/server/serveStdio.ts
Comment thread packages/server/src/server/serveStdio.ts
Comment thread packages/server/src/server/serveStdio.ts
…he opening factory

A handle.close() or wire close that landed while an opening arm was awaiting
the consumer factory (or the probe discard) was overwritten by the
continuation: the arm reassigned the connection state back to
probe/pinned/opening and connected a freshly built instance that nothing
would ever close, since the close paths had already run and are guarded by
the closing flag. The opening arms now re-check the torn-down condition after
every await, close a late-resolved instance instead of adopting it, and never
deliver the message that triggered the build. Covered by two new tests that
close the handle while a gated factory is mid-construction (legacy opening
and server/discover probe) and assert the instance is closed, nothing is
delivered to it, and the connection state is not resurrected.
…io probe window to the modern era

Inside the probe window only a server/discover request had special handling,
so any other modern-classified message - including a notification carrying a
valid envelope, such as a notifications/cancelled sent for a timed-out probe -
fell into the pinning branch and committed the connection. A legitimate
fallback initialize after that was rejected with -32004 instead of being
served, contradicting the documented contract that only a non-discover
enveloped request commits the era. Enveloped notifications received during
the probe window are now delivered to the probe instance without changing
phase; non-discover enveloped requests still pin the modern era. New tests
cover probe -> enveloped notifications/cancelled -> initialize falling back to
a fresh legacy instance, and probe -> enveloped request still committing (a
later initialize is rejected).
…JSDoc

The factory contract is shared by both serving entries, but its JSDoc only
described the HTTP per-request semantics. The McpRequestContext and
McpServerFactory blocks now state when each entry calls the factory (per HTTP
request vs per stdio connection, plus the discarded server/discover probe
instance), what era 'legacy' means under each entry, and that authInfo and
requestInfo are HTTP-only fields.
The migration guide entries for stdio serving now describe the change purely
from a v1 reader's perspective: the hand-constructed Server/McpServer +
StdioServerTransport pattern still works and serves only the 2025-era
protocol; serving the 2026-07-28 revision (or both eras) on stdio goes
through serveStdio, by moving the server construction into the factory. The
narration of an interim option that existed only in earlier 2.0 alphas is
removed from both guides; that history stays in the changeset.
Comment on lines 105 to 118
cacheHints?: Partial<Record<CacheableResultMethod, CacheHint>>;
};

/**
* Permissive params schema for the `server/discover` registration on servers
* that declared modern-era support. The discover request carries only the
* per-request `_meta` envelope, which the protocol layer lifts and validates
* before dispatch — and a long-lived dual-era instance is never bound to a
* single era, so the spec-method registration form (which resolves its
* dispatch schema from the instance era) cannot be used here.
*/
const DISCOVER_PARAMS_SCHEMA = z.looseObject({});

/**
* Whether a message's params carry a per-request envelope claim that is both
* well-formed and names a modern protocol revision.
*
* The per-message form of the inbound classifier's `initialize` precedence
* rule: only such a claim overrides the `initialize` ⇒ legacy-handshake
* classification — a message carrying a valid modern envelope is a modern
* request regardless of its method name, and the modern era then answers
* `initialize` exactly like any other method it does not define
* (method-not-found). A malformed claim, or one naming a pre-2026 revision,
* keeps the legacy-handshake routing unchanged.
*/
function carriesValidModernEnvelopeClaim(params: unknown): boolean {
if (!hasEnvelopeClaim(params)) {
return false;
}
const claimedVersion = envelopeClaimVersion(params);
if (claimedVersion === undefined || !isModernProtocolVersion(claimedVersion)) {
return false;
}
const meta = requestMetaOf(params);
return meta !== undefined && validateEnvelopeMeta(meta).length === 0;
}

/*
* Package-internal hooks for the per-request (2026-07-28) HTTP serving entry.
* Package-internal hooks for the 2026-07-28 serving entries (the per-request
* HTTP entry `createMcpHandler` and the connection-pinned stdio entry
* `serveStdio`).
*
* The connection-scoped client-identity fields and the modern-only handler set are
* private to `Server`; the per-request entry in this package needs to write/install
* them on the fresh instance it gets from a consumer factory. The static initializer
* private to `Server`; the serving entries in this package need to write/install
* them on the fresh instance they get from a consumer factory. The static initializer
* below hands these module-scoped closures privileged access; the exported wrappers
* are imported by sibling modules in this package only and are deliberately NOT
* re-exported from the package index (they are not public API).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The deprecated getClientCapabilities() / getClientVersion() JSDoc says instances serving the 2026-07-28 era "are backfilled per request from the validated envelope", but only createMcpHandler performs that backfill (seedClientIdentityFromEnvelope) — serveStdio's connectInstance() never does, so on 2026-pinned stdio connections (introduced by this PR) the accessors return undefined, exactly as the migration guide added in this PR documents. Qualify the two JSDoc blocks: backfilled per request under createMcpHandler; undefined on serveStdio 2026-pinned connections (handlers read ctx.mcpReq.envelope).

Extended reasoning...

What the bug is. The deprecated accessors Server.getClientCapabilities() and Server.getClientVersion() (packages/server/src/server/server.ts, JSDoc unchanged by this diff) both state: "The accessor remains functional — instances serving the 2026-07-28 era are backfilled per request from the validated envelope." That sentence was accurate while the only modern-serving instances were the per-request ones built by createMcpHandler, which calls seedClientIdentityFromEnvelope for every request (createMcpHandler.ts:481). This PR introduces a second class of modern-serving instances — connection-pinned instances built by serveStdio — for which the claim is false.

The code path. serveStdio's connectInstance() (packages/server/src/server/serveStdio.ts) only calls setNegotiatedProtocolVersion(server, revision) and installModernOnlyHandlers(...) for a modern-pinned instance; it never imports or calls seedClientIdentityFromEnvelope (a grep confirms the only caller is createMcpHandler.ts). So _clientCapabilities / _clientVersion on a 2026-pinned stdio instance stay at their initial undefined, and the accessors return undefined for the entire connection.

Step-by-step proof.

  1. A 2026-capable client opens a serveStdio connection with an enveloped request claiming 2026-07-28; the entry pins a modern instance via connectInstance('modern', '2026-07-28') — no identity seeding happens.
  2. A handler on this connection calls server.getClientCapabilities() or server.getClientVersion().
  3. _oninitialize never ran (the modern era has no initialize) and no per-request backfill ever runs on this instance, so both accessors return undefined.
  4. The hover JSDoc, however, told the consumer the accessor "remains functional" on 2026-era instances because it is "backfilled per request from the validated envelope" — code written against that promise (e.g. logging or capability checks based on the accessor) silently sees undefined on every serveStdio modern connection.

Why nothing prevents it / why it is PR-attributable. Nothing cross-checks JSDoc prose against the entries that bind instances. The JSDoc was correct before this PR; the PR adds the new instance class and its own migration.md / migration-SKILL.md prose explicitly says these two accessors return undefined on 2026-pinned stdio connections — so the accessor JSDoc now contradicts both the implementation and the migration guide added in this same PR.

Impact. Docs-only: behavior is unaffected, and the migration guide (and docs/server.md) already point handlers at ctx.mcpReq.envelope. The only harm is a misleading hover doc on the deprecated accessors for consumers adopting the new stdio entry.

How to fix. One-sentence qualification on each of the two JSDoc blocks, e.g.: "instances serving the 2026-07-28 era through createMcpHandler are backfilled per request from the validated envelope; on connections pinned to that era by serveStdio the accessor returns undefined — read ctx.mcpReq.envelope instead." (Note this is distinct from the existing review comment about getNegotiatedProtocolVersion() in migration.md — that one is the converse direction, prose claiming undefined where the code returns the pinned revision; this one is the accessor JSDoc claiming a backfill that the new entry never performs.)

@felixweinberger felixweinberger merged commit 96bf12f into v2-2026-07-28 Jun 18, 2026
17 checks passed
@felixweinberger felixweinberger deleted the fweinberger/serve-stdio branch June 18, 2026 10:16
felixweinberger added a commit that referenced this pull request Jun 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant